fix(service-automation): scope a map node's progress state to one execution of its collection - #15648
Conversation
…llection Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…xecution of its collection `map` tracks progress through its collection in the flow variable `<nodeId>.$mapState` and wrote it into the flow's SHARED variable scope without ever removing it. A `loop` body region runs in that same scope by construction, so the state written by iteration 1 was still there when iteration 2 entered the map: it read back `started === collection.length`, concluded there was nothing left to start, and returned success. Measured on the real engine: 5 iterations x 2 items produced 2 child runs instead of 10, the map step reported `success` on all five iterations, and the run finished `completed` with `failed = 0` — silent partial work, invisible to the very run-level counter built to expose that class. The state key is now removed once the collection is exhausted, making its lifetime one execution of the collection rather than the enclosing scope's. The durable-pause path is deliberately untouched: the write made before returning `suspend: true` is the mechanism a resume depends on, because `resumeInternal` rebuilds the scope from the snapshot taken at that suspend and can never see a later write. Only the terminal path clears the key. A test pins that half — an unconditional delete leaves the loop assertions green and fails only the resume pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 826e703f56407a720d02b8ed03f1bf257a0e4488 && git checkout 826e703f56407a720d02b8ed03f1bf257a0e4488
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1be26b0de27b2c45d208a20edffefbac1e4892c2 ac3079c437e48e2b8bc5aa57286a31454b739e67 && git checkout -B drift-repro 1be26b0de27b2c45d208a20edffefbac1e4892c2 && git merge --no-ff ac3079c437e48e2b8bc5aa57286a31454b739e67
node scripts/docs-audit/affected-docs.mjs --json 1be26b0de27b2c45d208a20edffefbac1e4892c2 |
Clause-② contract review — PR #15648 (card #15616) · PASSTier line. Reviewed at head What was driven (not read)Single pin file via the package's own vitest (the PM already re-certified the 108-file package union; this round is discrimination only). Restores by
⭐ The mutation table's second row reproduces exactly: B leaves the three loop pins green and fails only the resume pin. Two independent naive spellings (B, C) are both caught by pin 4 alone, and A shows all four pins discriminate against the unfixed source. Under B the pin dies at its snapshot assertion before reaching the "every item once" assertion, so I also ran my own probes under B: the nested map-in-map probe shows the predicted re-run signature directly — Five additional probes of my own, all green at head (file lived only in my worktree, deleted after):
The eight attack items
Notes (non-blocking)
NOT MEASURED (by this review)
Verdict: PASS. One behavioural line; the resume argument holds on every link and under driving; the naive fix is caught by the resume pin alone in two spellings; the census zero re-derives independently; the follow-up is filed correctly. Landing is the PM's. Generated by Claude Code |
Fixes #15616
A
mapnode inside aloopbody ran its collection on the first iteration only. Iterations 2..n ran nothing, the map step reportedsuccesson every one of them, and the run finishedcompleted.Mechanism
maptracks its progress through the collection in the flow variablenodeId.$mapState, and wrote it into the flow's shared variable scope without ever removing it. Aloopbody region runs in that same scope by construction —runRegionis handed the caller'sMapdeliberately, because the iterator variable and the body's mutations have to stay visible to the rest of the flow. So the state written by iteration 1 was still there when iteration 2 entered the map: it read backstarted === collection.length, correctly concluded there was nothing left to start, and returned.The lifetime was the defect, not the key.
$mapStatewas scoped to the run; it should be scoped to one execution of the collection.The fix
One line of behaviour: the terminal path now
deletes the state key instead of re-setting it.The durable-pause path is deliberately untouched, and that is the load-bearing half of this PR. The card's own scope note warned that "delete the state key when the collection is exhausted" was the obvious candidate and was NOT MEASURED, because a map resumed mid-collection depends on that state surviving. That is established here rather than assumed:
mapwrites$mapStatein two places — the suspend arm (map-node.ts, before returningsuspend: true) and the terminal arm. Only the terminal one changes.Object.fromEntries(variables)) into theSuspendedRun. On resume,resumeInternalrebuilds the scope withnew Map(Object.entries(run.variables))— a fresh map from that snapshot. So the suspend-time write is the only write to this key a resume can ever read, and no terminal-path change can reach it.map-node.ts(resume-authority-gate.test.ts) reads the key off a suspended run's snapshot, so it is on the same side of that line.A pausing
mapis in any case unreachable from inside aloopbody:runRegionconverts a durable pause inside a structured region into an error. The resume path is live for a top-level map, which is what the new resume pin exercises.Tests
New file
map-in-loop-iteration-state.test.ts— four pins on the realAutomationEngine, no mock of the mechanism:completedwithsummary.failed === 0and the work happened — pinning whyfailedcould never have been the instrument that caught this;nodeId.$mapStateis absent from the scope after the map completes (the mechanism asserted directly, not inferred from the child-run count);$mapStateis present in the suspend snapshot withstarted: 1, and driving the pauses through runs every item exactly once.Both pins were mutated to confirm they discriminate, restoring from
HEADeach time and verifying the restore bygit diff HEADplus a blob-hash comparison:deletereverted toset(the pre-fix spelling)That second row is the point. The obvious fix makes the reported symptom go away and silently breaks resume, and the resume pin is the only thing standing between the two.
Census — the second deliverable
Searched for the predicate (executors that persist state under a node-scoped key in the shared scope) rather than recalling which ones do. Every
variables.set/variables.deletein every builtin, every key expression derived fromnode.id, module-scope mutable state, writes into the shared context, and everyregisterNodeExecutorin the repo — including the two inplugin-approvals, which are outside this package.mapis the only one. Every other scope write is an author-named output variable (crud,screen,subflow,logic,try_catch'serrorVariable) or a loop's iterator/index binding — written each time, never read back as progress.waitand the approval nodes only read the scope;parallelnever touches it. The zero has a firing control: the same patterns hitmap's seven writes, including the defective one, and hitwait's and the approval nodes' reads.⇒ No second instance of the identical mechanism, so nothing beyond
mapis changed here.Found and NOT fixed
mapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 — the same key reached through the other arm. When a pausingmapsits inside a contained region, the suspend write lands but the pause is refused byrunRegion, leaving residue no terminal path can clear. Measured: 3 iterations x 2 items, 0 items ever completed, only 2 of 3 iterations reached the catch, and iteration 3 returnedsuccesshaving run nothing. Closing it needs a ruling on which layer owns cleanup when a suspend is refused, so it is filed rather than guessed.FlowRunSummary's two paragraphs disagree for a subflow parent —failedis declared a node fold, while the summary is declared to answer "what did this run cause" and roll a child's totals up #15617 is not addressed here; that run'ssummary.failed = 0over two contained failures is recorded on service-automation: a PAUSINGmapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 as a data point for it.Gates
pnpm lint(full repo) exit 0; packagetest108 files / 1294 tests andtypecheckboth exit 0, re-run at the final commitac3079c43. ADR-0087 via the real invocationnode scripts/check-adr-0087-registration.mjs --base origin/main --head ac3079c43exit 0 (1 non-breaking changeset(s) seen), plus its--self-test. The gate set was re-derived from the actual changed files withscripts/pm/dispatch-gates.mjs; the derived families were run and each zero was read off the gate's own verdict line with the exit code captured by redirect, never through a pipe.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code